Deux objectifs du présent notebook :
Dans un premier temps, nous allons analyser notre jeu de données du point de vue marketing. L'idée est de repérer les features qui ont du sens, afin de cibler les clients qui sembleraient intéressants en termes de vente.
Pour ce faire, nous allons nous appuyer sur la méthode RFM. Ensuite nous allons mettre en place des algorithmes de clustering permettant de déterminer une typologie comportementale de chaque client.
Ci-dessous la table des matières du présent notebook :
La méthode RFM permet d’établir des groupes de clients homogènes suivant trois critères :
Un client qui achète une fois et disparaît dans la nature n’est pas le même qu’un client régulier qui achète à une fréquence importante. Un client qui dépense de grosses sommes n’est pas non plus le même qu’un client qui dépense peu.
Nous allons essayer d'identifier les caractéristiques client suivant leur comportement d'achat.
from my_functions import *
import joblib
%reload_ext autoreload
pd.options.display.float_format = '{:.3f}'.format
# Chargement du jeu de données
data = joblib.load('df_suite_eda')
data.shape
(112782, 24)
data.head()
| order_id | customer_id | order_status | order_purchase_timestamp | order_approved_at | order_delivered_carrier_date | order_delivered_customer_date | order_estimated_delivery_date | order_item_id | product_id | ... | payment_sequential | payment_type | payment_installments | payment_value | review_score | product_category_name | customer_unique_id | customer_zip_code_prefix | customer_city | customer_state | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | e481f51cbdc54678b7cc49136f2d6af7 | 9ef432eb6251297304e76186b10a928d | delivered | 2017-10-02 10:56:33 | 2017-10-02 11:07:15 | 2017-10-04 19:55:00 | 2017-10-10 21:25:13 | 2017-10-18 | 1 | 87285b34884572647811a353c7ac498a | ... | 1 | credit_card | 1 | 18.120 | 4 | housewares | 7c396fd4830fd04220f754e42b4e5bff | 3149 | sao paulo | SP |
| 1 | e481f51cbdc54678b7cc49136f2d6af7 | 9ef432eb6251297304e76186b10a928d | delivered | 2017-10-02 10:56:33 | 2017-10-02 11:07:15 | 2017-10-04 19:55:00 | 2017-10-10 21:25:13 | 2017-10-18 | 1 | 87285b34884572647811a353c7ac498a | ... | 3 | voucher | 1 | 2.000 | 4 | housewares | 7c396fd4830fd04220f754e42b4e5bff | 3149 | sao paulo | SP |
| 2 | e481f51cbdc54678b7cc49136f2d6af7 | 9ef432eb6251297304e76186b10a928d | delivered | 2017-10-02 10:56:33 | 2017-10-02 11:07:15 | 2017-10-04 19:55:00 | 2017-10-10 21:25:13 | 2017-10-18 | 1 | 87285b34884572647811a353c7ac498a | ... | 2 | voucher | 1 | 18.590 | 4 | housewares | 7c396fd4830fd04220f754e42b4e5bff | 3149 | sao paulo | SP |
| 3 | 128e10d95713541c87cd1a2e48201934 | a20e8105f23924cd00833fd87daa0831 | delivered | 2017-08-15 18:29:31 | 2017-08-15 20:05:16 | 2017-08-17 15:28:33 | 2017-08-18 14:44:43 | 2017-08-28 | 1 | 87285b34884572647811a353c7ac498a | ... | 1 | credit_card | 3 | 37.770 | 4 | housewares | 3a51803cc0d012c3b5dc8b7528cb05f7 | 3366 | sao paulo | SP |
| 4 | 0e7e841ddf8f8f2de2bad69267ecfbcf | 26c7ac168e1433912a51b924fbd34d34 | delivered | 2017-08-02 18:24:47 | 2017-08-02 18:43:15 | 2017-08-04 17:35:43 | 2017-08-07 18:30:01 | 2017-08-15 | 1 | 87285b34884572647811a353c7ac498a | ... | 1 | credit_card | 1 | 37.770 | 5 | housewares | ef0996a1a279c26e7ecbd737be23d235 | 2290 | sao paulo | SP |
5 rows × 24 columns
A présent, nous allons créer un nouveau dataframe qui regroupera les caractéristiques qui nous permettront de calculer les R,F & M. Le je de données comprendra les features suivantes :
order_purchase_timestamp : la date d'achatpayment_value : le prix payécustomer_id, order_id : identifiant du client & commandeAprès vérification, la variable payment_value correspond bien au prix total payé.
orders = data.groupby(['order_id', 'order_purchase_timestamp', 'customer_id']).agg(
{'payment_value': lambda x: x.sum()}).reset_index()
orders.head()
| order_id | order_purchase_timestamp | customer_id | payment_value | |
|---|---|---|---|---|
| 0 | 00010242fe8c5a6d1ba2dd792cb16214 | 2017-09-13 08:59:02 | 3ce436f183e68e07877b285a838db11a | 72.190 |
| 1 | 00018f77f2f0320c557190d7a144bdd3 | 2017-04-26 10:53:06 | f6dd3ec061db4e3987629fe6b26e5cce | 259.830 |
| 2 | 000229ec398224ef6ca0657da4fc703e | 2018-01-14 14:33:31 | 6489ae5e4333f3693df5ad4372dab6d3 | 216.870 |
| 3 | 00024acbcdf0a6daa1e931b038114c75 | 2018-08-08 10:00:35 | d4eb9395c8c0431ee92fce09860c5a06 | 25.780 |
| 4 | 00042b26cf59d7ce69dfabb4e55b4fd9 | 2017-02-04 13:57:51 | 58dbd0b2d70206bf40e62cd34e84d795 | 218.040 |
Essayons de définir la période concernée par notre analyse.
print('Les commandes ont été passées du {} au {}.'.format(data['order_purchase_timestamp'].min(),
data['order_purchase_timestamp'].max()))
Les commandes ont été passées du 2016-10-03 09:44:50 au 2018-08-29 15:00:37.
data.groupby(data['order_purchase_timestamp'].dt.year)\
.agg({"order_id": "nunique"})\
.plot(figsize=(10, 6), kind="bar", color=['green'],
title="Répartition des commandes par année",
ylabel="Nombre de commandes",
xlabel="Année",
legend=False)
plt.show()
La plage est assez large : d'Octobre 2016 jusqu'à fin Août 2018. Nous allons la réduire à 12 mois :
start = dt.datetime.strptime('2017-01-01 00:00:00', '%Y-%m-%d %H:%M:%S')
end = dt.datetime.strptime('2018-01-01 00:00:00', '%Y-%m-%d %H:%M:%S')
Afin de calculer la Récence, il faut savoir à quand date le dernier achat, autrement dit connaître le nombre de jours écoulés depuis cet achat. Les variables qui nous seront utiles sont les suivantes : customer_unique_id en fonction de order_purchase_timestamp.
# Définition du cadre temporel
rec = data.loc[(data['order_purchase_timestamp'] > start)
& (data['order_purchase_timestamp'] < end)]
# Identification de la date max sur l'axe du temps (dernier achat)
rec = rec[['customer_unique_id', 'order_purchase_timestamp']]
rec = rec.groupby('customer_unique_id')[
'order_purchase_timestamp'].max().reset_index()
rec.columns = ['CustomerID', 'LastPurchase']
# ajout nouvelle colonne : calcul du nombre de jours depuis le dernier achat du client
rec['Récence'] = (rec['LastPurchase'].max() -
rec['LastPurchase']).dt.days
# On supprime la colonne 'LastPurchase'
rec.drop(columns='LastPurchase', inplace=True)
rec.head()
| CustomerID | Récence | |
|---|---|---|
| 0 | 0000f46a3911fa3c0805444483337064 | 296 |
| 1 | 0000f6ccb0745a6a4b88665a16c9f078 | 80 |
| 2 | 0004aac84e0df4da2b147fca70cf8255 | 47 |
| 3 | 0005e1862207bf6ccc02e4228effd9a0 | 301 |
| 4 | 0006fdc98a402fceb4eb0ee528f6a8d4 | 166 |
La Fréquence correspond au nombre de commandes effectuées par chaque client :
freq = data.loc[(data['order_purchase_timestamp'] > start)
& (data['order_purchase_timestamp'] < end)]
freq = freq[['customer_unique_id', 'order_id']]
# Calcul du nombre de commandes par client
freq = freq.groupby('customer_unique_id')['order_id'].count().reset_index()
freq.columns = ['CustomerID', 'Fréquence']
freq.head()
| CustomerID | Fréquence | |
|---|---|---|
| 0 | 0000f46a3911fa3c0805444483337064 | 1 |
| 1 | 0000f6ccb0745a6a4b88665a16c9f078 | 1 |
| 2 | 0004aac84e0df4da2b147fca70cf8255 | 1 |
| 3 | 0005e1862207bf6ccc02e4228effd9a0 | 1 |
| 4 | 0006fdc98a402fceb4eb0ee528f6a8d4 | 1 |
Maintenant que nous avons la donnée Recency & Frequency, il ne reste plus qu'à rajouter la valeur monetaire. Il suffit d'additionner le total des dépenses :
monet = data.loc[(data['order_purchase_timestamp'] > start) &
(data['order_purchase_timestamp'] < end)]
monet = monet[['customer_unique_id', 'payment_value']]
# Montant total par client
monet = monet.groupby('customer_unique_id')[
'payment_value'].sum().reset_index()
monet.columns = ['CustomerID', 'Montant']
monet.head()
| CustomerID | Montant | |
|---|---|---|
| 0 | 0000f46a3911fa3c0805444483337064 | 86.220 |
| 1 | 0000f6ccb0745a6a4b88665a16c9f078 | 43.620 |
| 2 | 0004aac84e0df4da2b147fca70cf8255 | 196.890 |
| 3 | 0005e1862207bf6ccc02e4228effd9a0 | 150.120 |
| 4 | 0006fdc98a402fceb4eb0ee528f6a8d4 | 29.000 |
Fusionnons les trois mini datasets :
rfm_base = rec.merge(freq, on='CustomerID')
rfm = rfm_base.merge(monet, on='CustomerID')
rfm.head()
| CustomerID | Récence | Fréquence | Montant | |
|---|---|---|---|---|
| 0 | 0000f46a3911fa3c0805444483337064 | 296 | 1 | 86.220 |
| 1 | 0000f6ccb0745a6a4b88665a16c9f078 | 80 | 1 | 43.620 |
| 2 | 0004aac84e0df4da2b147fca70cf8255 | 47 | 1 | 196.890 |
| 3 | 0005e1862207bf6ccc02e4228effd9a0 | 301 | 1 | 150.120 |
| 4 | 0006fdc98a402fceb4eb0ee528f6a8d4 | 166 | 1 | 29.000 |
rfm.shape
(41094, 4)
A présent, nous allons calculer les scores RFM.
Dans un premier temps, il faut parvenir à diviser la population d'une distribution de façon homogène. Affichons la distribution des trois variables :
"""def distribution (df) :
plt.figure(1, figsize=(15,6))
n=0
for k in ['Récence','Fréquence','Montant']:
n+=1
plt.subplot(1,3,n)
plt.subplots_adjust(hspace=0.5, wspace=0.5)
sns.distplot(df[k], bins=20)
plt.title('Distribution de {}'.format(k))
plt.show()"""
"def distribution (df) :\n plt.figure(1, figsize=(15,6))\n n=0\n for k in ['Récence','Fréquence','Montant']:\n n+=1\n plt.subplot(1,3,n)\n plt.subplots_adjust(hspace=0.5, wspace=0.5)\n sns.distplot(df[k], bins=20)\n plt.title('Distribution de {}'.format(k))\n plt.show()"
distribution(rfm)
/home/sylwia/.local/lib/python3.9/site-packages/seaborn/distributions.py:2619: FutureWarning: `distplot` is a deprecated function and will be removed in a future version. Please adapt your code to use either `displot` (a figure-level function with similar flexibility) or `histplot` (an axes-level function for histograms). warnings.warn(msg, FutureWarning) /home/sylwia/.local/lib/python3.9/site-packages/seaborn/distributions.py:2619: FutureWarning: `distplot` is a deprecated function and will be removed in a future version. Please adapt your code to use either `displot` (a figure-level function with similar flexibility) or `histplot` (an axes-level function for histograms). warnings.warn(msg, FutureWarning) /home/sylwia/.local/lib/python3.9/site-packages/seaborn/distributions.py:2619: FutureWarning: `distplot` is a deprecated function and will be removed in a future version. Please adapt your code to use either `displot` (a figure-level function with similar flexibility) or `histplot` (an axes-level function for histograms). warnings.warn(msg, FutureWarning)
Les distributions ne sont pas symétriques :
Analysons les distributions une par une.
Récence
sns.boxplot(x=rfm["Récence"])
plt.show()
RAS du côté de la Récence. Les clients sont les plus nombreux à avoir visité le site il y a entre environ 50 et 220 jours.
Fréquence
sns.boxplot(x=rfm["Fréquence"])
plt.show()
Du côte de la Fréquence nous observons de très nombreux outliers. Pourtant, il ne s'agit pas de valeurs aberrantes. Il s'agit de clients qui commandent avec une fréquence très diversifiée : entre 0 et plus de 70 fois.
rfm['Fréquence'].value_counts(normalize=True)
1 0.848 2 0.112 3 0.021 4 0.011 5 0.003 6 0.003 8 0.001 7 0.001 10 0.000 12 0.000 11 0.000 9 0.000 13 0.000 14 0.000 15 0.000 24 0.000 19 0.000 22 0.000 20 0.000 21 0.000 26 0.000 38 0.000 18 0.000 75 0.000 35 0.000 Name: Fréquence, dtype: float64
Pour la suite de l'analyse, nous allons garder les clients qui ont commandé entre une et 12 fois, c'est à dire qui représentent plus de 0.001% des clients.
rfm = rfm.loc[rfm['Fréquence'].isin([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12])]
Montant
sns.boxplot(x=rfm["Montant"])
plt.show()
La distribution des prix est également très étalée. Vérifions les valeurs extrêmes :
rfm['Montant'].describe()
count 41068.000 mean 208.521 std 703.273 min 10.070 25% 64.000 50% 111.105 75% 201.102 max 109312.640 Name: Montant, dtype: float64
La somme payée minimale s'élève à 10 réis. La somme maximale est aberrante : 109312.640000 réis. Faisons appel à la méthode des inter-quartiles pour vérifier les limites :
def limit(df, i):
Q1 = df[i].quantile(0.5)
Q3 = df[i].quantile(0.95)
IQR = Q3 - Q1
# Calcul des valeurs extrêmes
lower_limit = df[i].quantile(0.5) - (IQR * 1.5)
lower_limit_extreme = df[i].quantile(0.5) - (IQR * 3)
upper_limit = df[i].quantile(0.95) + (IQR * 1.5)
upper_limit_extreme = df[i].quantile(0.5) + (IQR * 3)
print('Lower Limit:', lower_limit)
print('Lower Limit Extreme:', lower_limit_extreme)
print('Upper Limit:', upper_limit)
print('Upper Limit Extreme:', upper_limit_extreme)
# Calcul des pourcentages de valeurs aberrantes
def percent_outliers(df, i):
Q1 = df[i].quantile(0.5)
Q3 = df[i].quantile(0.95)
IQR = Q3 - Q1
lower_limit = df[i].quantile(0.5) - (IQR * 1.5)
lower_limit_extreme = df[i].quantile(0.5) - (IQR * 3)
upper_limit = df[i].quantile(0.95) + (IQR * 1.5)
upper_limit_extreme = df[i].quantile(0.95) + (IQR * 3)
# Le pourcentage de valeurs aberrantes par rapport au total des données
print('Lower Limit: {} %'.format(
df[(df[i] >= lower_limit)].shape[0] / df.shape[0]*100))
print('Lower Limit Extreme: {} %'.format(
df[(df[i] >= lower_limit_extreme)].shape[0]/df.shape[0]*100))
print('Upper Limit: {} %'.format(
df[(df[i] >= upper_limit)].shape[0] / df.shape[0]*100))
print('Upper Limit Extreme: {} %'.format(
df[(df[i] >= upper_limit_extreme)].shape[0]/df.shape[0]*100))
print(limit(rfm, 'Montant'))
print('-'*50)
print(percent_outliers(rfm, 'Montant'))
Lower Limit: -708.286500000001 Lower Limit Extreme: -1527.678000000002 Upper Limit: 1476.7575000000015 Upper Limit Extreme: 1749.888000000002 None -------------------------------------------------- Lower Limit: 100.0 % Lower Limit Extreme: 100.0 % Upper Limit: 1.1931430797701374 % Upper Limit Extreme: 0.5162170059413655 % None
Nous allons éliminer les valeurs supérieures à 1 500 réis, car elles se situent en dehors de la limite maximale de la distribution des données (on considère comme valeur extrême tout ce qui est supérieur à moyenne + 3 écart-type ou inférieur à moyenne – 3 écart-type). Affichons le nombre de clients concernés :
rfm[rfm['Montant'] > 1500].count()
CustomerID 471 Récence 471 Fréquence 471 Montant 471 dtype: int64
outliers_drop = rfm[(rfm['Montant'] > 1500)].index
rfm.drop(outliers_drop, inplace=True)
# Affichage des distributions suite aux modifications apportées
distribution(rfm)
/home/sylwia/.local/lib/python3.9/site-packages/seaborn/distributions.py:2619: FutureWarning: `distplot` is a deprecated function and will be removed in a future version. Please adapt your code to use either `displot` (a figure-level function with similar flexibility) or `histplot` (an axes-level function for histograms). warnings.warn(msg, FutureWarning) /home/sylwia/.local/lib/python3.9/site-packages/seaborn/distributions.py:2619: FutureWarning: `distplot` is a deprecated function and will be removed in a future version. Please adapt your code to use either `displot` (a figure-level function with similar flexibility) or `histplot` (an axes-level function for histograms). warnings.warn(msg, FutureWarning) /home/sylwia/.local/lib/python3.9/site-packages/seaborn/distributions.py:2619: FutureWarning: `distplot` is a deprecated function and will be removed in a future version. Please adapt your code to use either `displot` (a figure-level function with similar flexibility) or `histplot` (an axes-level function for histograms). warnings.warn(msg, FutureWarning)
La skewness des distributions de la Fréquence et Montant est un peu plus équlibrée. Plus loin dans l'analyse, nous allons procéder à un scaling de données.
Pour attritbuer un score à un client, il faut diviser la population de façon homogène. Commençons par visualiser les quantiles de chaque variable :
quantiles = rfm[['Récence', 'Fréquence', 'Montant']
].quantile([.2, .4, .6, .8]).to_dict()
quantiles
{'Récence': {0.2: 37.0, 0.4: 90.0, 0.6: 154.0, 0.8: 230.0},
'Fréquence': {0.2: 1.0, 0.4: 1.0, 0.6: 1.0, 0.8: 1.0},
'Montant': {0.2: 55.83200000000001,
0.4: 87.03,
0.6: 135.95,
0.8: 229.46800000000047}}
Les quantiles de la feature Fréquence ne sont pas cohérents. Jetons un oeil sur les indicateurs statistiques :
rfm.Fréquence.describe()
count 40597.000 mean 1.203 std 0.614 min 1.000 25% 1.000 50% 1.000 75% 1.000 max 12.000 Name: Fréquence, dtype: float64
rfm['Fréquence'].value_counts(normalize=True)
1 0.855 2 0.112 3 0.019 4 0.009 5 0.002 6 0.002 7 0.000 8 0.000 12 0.000 9 0.000 10 0.000 Name: Fréquence, dtype: float64
Nous constatons que la valeur 1 représente plus de 86% des clients et la valeur 2 environ 11%, donc à elles deux près de 96% du total. Essayons de trouver un bon compromis pour attribuer les notes de façon fiable (un client ayant commandé 1 fois ne peut pas avoir le même score qu'un client qui a commandé plus de 10 fois).
Nous allons assigner les notes pour la Fréquence de façon arbitraire, suivant la fréquence de commandes :
En règle générale, on attribue :
# Attribution d'une note moindre pour la Récence
def r_score(x):
if x <= quantiles['Récence'][.2]:
return 5
elif x <= quantiles['Récence'][.4]:
return 4
elif x <= quantiles['Récence'][.6]:
return 3
elif x <= quantiles['Récence'][.8]:
return 2
else:
return 1
# Attribution d'une note plus élevée pour la Fréquence
def f_score(x, ft):
if x == 1:
return 1
elif x == 2:
return 2
elif x == 3:
return 3
elif x <= 6:
return 4
else:
return 5
# Attribution d'une note plus élevée pour le Montant
def m_score(x):
if x <= quantiles['Montant'][.2]:
return 1
elif x <= quantiles['Montant'][.4]:
return 2
elif x <= quantiles['Montant'][.6]:
return 3
elif x <= quantiles['Montant'][.8]:
return 4
else:
return 5
Nous pouvons enfin ajouter les résultats à notre tableau RFM :
Une fois les valeurs des quartiles additionnées, chaque client se verra attribuer son propre score RFM.
# les notes RFM attribuées
rfm['R'] = rfm['Récence'].apply(lambda x: r_score(x))
rfm['F'] = rfm['Fréquence'].apply(lambda x: f_score(x, 'Fréquence'))
rfm['M'] = rfm['Montant'].apply(lambda x: m_score(x))
# le score final RFM
rfm['RFM_Score'] = rfm['R'].map(str) + rfm['F'].map(str) + rfm['M'].map(str)
rfm.head()
| CustomerID | Récence | Fréquence | Montant | R | F | M | RFM_Score | |
|---|---|---|---|---|---|---|---|---|
| 0 | 0000f46a3911fa3c0805444483337064 | 296 | 1 | 86.220 | 1 | 1 | 2 | 112 |
| 1 | 0000f6ccb0745a6a4b88665a16c9f078 | 80 | 1 | 43.620 | 4 | 1 | 1 | 411 |
| 2 | 0004aac84e0df4da2b147fca70cf8255 | 47 | 1 | 196.890 | 4 | 1 | 4 | 414 |
| 3 | 0005e1862207bf6ccc02e4228effd9a0 | 301 | 1 | 150.120 | 1 | 1 | 4 | 114 |
| 4 | 0006fdc98a402fceb4eb0ee528f6a8d4 | 166 | 1 | 29.000 | 2 | 1 | 1 | 211 |
# Vérification de l'attribution des notes pour la Fréquence :
rfm_f = rfm.groupby('Fréquence').agg({
'R': 'max',
'F': 'max',
'M': ['max', 'count']
}).round(1)
rfm_f
| R | F | M | ||
|---|---|---|---|---|
| max | max | max | count | |
| Fréquence | ||||
| 1 | 5 | 1 | 5 | 34718 |
| 2 | 5 | 2 | 5 | 4531 |
| 3 | 5 | 3 | 5 | 775 |
| 4 | 5 | 4 | 5 | 362 |
| 5 | 5 | 4 | 5 | 84 |
| 6 | 5 | 4 | 5 | 82 |
| 7 | 5 | 5 | 5 | 16 |
| 8 | 5 | 5 | 5 | 12 |
| 9 | 5 | 5 | 5 | 6 |
| 10 | 5 | 5 | 5 | 4 |
| 12 | 5 | 5 | 5 | 7 |
Les notes sont correctement réparties. Nous allons pouvoir passer à l'interprétation des résultats de la segmentation RFM.
Nous venons de créer un grand nombre de combinaisons de scores possibles (5 scores puissance 3 - 125 segments au total). Pour la suite de notre analyse, nous allons en sélectionner une partie. Ci-dessous les scores les plus représentatifs :
rfm.groupby('RFM_Score').size().sort_values(ascending=False)[:15]
RFM_Score 111 1694 312 1676 513 1652 511 1609 512 1603 211 1587 212 1573 213 1496 313 1471 311 1468 411 1459 112 1448 514 1446 412 1445 214 1418 dtype: int64
Parmi les 15 premières positions (entre 2662 et 3120 clients) se placent :
Essayons de visualiser les résultats :
# Affichage de clients avec une récence moindre et un montant dépensé élevé
rfm[rfm["RFM_Score"] == '514'][:10]
| CustomerID | Récence | Fréquence | Montant | R | F | M | RFM_Score | |
|---|---|---|---|---|---|---|---|---|
| 39 | 003d56767e53e08671de00da3fba8d40 | 30 | 1 | 178.590 | 5 | 1 | 4 | 514 |
| 68 | 006e937bd5a5e32043018a6d1521b5d5 | 21 | 1 | 199.790 | 5 | 1 | 4 | 514 |
| 85 | 009000746b4fc73fe77ecc396fdb78d2 | 32 | 1 | 166.590 | 5 | 1 | 4 | 514 |
| 105 | 00a78b45ea275348c1a8081bef9ab61b | 2 | 1 | 182.290 | 5 | 1 | 4 | 514 |
| 107 | 00ab8b4d83051d5a41842abe7ff473fc | 29 | 1 | 144.660 | 5 | 1 | 4 | 514 |
| 170 | 010d09c16c5fee11226ca7c5492892de | 37 | 1 | 220.540 | 5 | 1 | 4 | 514 |
| 175 | 011702c19fd5dec0000a8dbbb5b98809 | 28 | 1 | 150.290 | 5 | 1 | 4 | 514 |
| 199 | 0137839e600d4edd0949a8ce7c171164 | 18 | 1 | 150.050 | 5 | 1 | 4 | 514 |
| 228 | 016201b260065cecbb315f4de0b0dc76 | 37 | 1 | 163.680 | 5 | 1 | 4 | 514 |
| 258 | 018ccb97f791b89dc7ae72b4a114b00a | 30 | 1 | 213.090 | 5 | 1 | 4 | 514 |
# Affichage de clients avec une récence très ancienne et un faible montant dépensé
rfm[rfm["RFM_Score"] == '111'][:10]
| CustomerID | Récence | Fréquence | Montant | R | F | M | RFM_Score | |
|---|---|---|---|---|---|---|---|---|
| 21 | 001f3c4211216384d5fe59b041ce1461 | 287 | 1 | 35.840 | 1 | 1 | 1 | 111 |
| 34 | 0036a074f98b80c4f1fc33dbbcf9c552 | 233 | 1 | 42.620 | 1 | 1 | 1 | 111 |
| 153 | 00e707efa5ad9f0e6bcaeeb87910693b | 258 | 1 | 34.860 | 1 | 1 | 1 | 111 |
| 180 | 011c3e6b4b11fa8a326a39e78deb6e66 | 299 | 1 | 18.830 | 1 | 1 | 1 | 111 |
| 211 | 014fa2f37ee690774a7505252a28daf3 | 284 | 1 | 39.420 | 1 | 1 | 1 | 111 |
| 213 | 0151b5ff5b5d642815f094f303e7052a | 269 | 1 | 49.520 | 1 | 1 | 1 | 111 |
| 246 | 0181f57a87c6a2f0e0fe2807f67aef0e | 235 | 1 | 29.100 | 1 | 1 | 1 | 111 |
| 255 | 018ad5828f8f66b69604caabd0aa78ca | 301 | 1 | 28.620 | 1 | 1 | 1 | 111 |
| 277 | 01a8a2fa13dad647949ff57927e63b0d | 276 | 1 | 53.950 | 1 | 1 | 1 | 111 |
| 299 | 01d46b5cad3cda9c22f52df41197a671 | 262 | 1 | 40.860 | 1 | 1 | 1 | 111 |
Analyse comportementale
Conformément aux règles du marketing moderne, nous allons tenter de "trier" les clients en fonction de leur comportement d'achat. L'objectif est d'identifier des tendances dans la manière dont les clients agissent dans leur parcours d’achat.
Nous allons assigner aux scores obtenus un label qui définit le type de comportement :
Les labels "positifs" :
Champions => Achètent souvent et beacoup.Loyal Customers => Achètent régulièrement. Attirés par les promos.Potential Loyalists => Clients assez récents, à fréquence moyenne.New Customers => Achats effectués récemment, mais pas souvent.Promising => Achats effectués récemment, sans dépenser beaucoup d'argent.Les labels "négatifs" :
Needing Attention => Sans activité récente, mais au-dessus de la moyenne de fréquence / récence / montant d'achat.About To Sleep => En-dessous de la moyenne de fréquence / récence / montant d'achat. Clients à réactiver.At Risk => Achats effectués régulièrement, mais dans le passé. Clients à récupérer.Can’t Loose Them => Achats effectués régulièrement, mais le client ne s'est pas manifesté depuis.Hibernating => Dernier achat date d'il y a longtemps. Client probablement perdu.label_map = {
# labels négatifs
r'[1-2][1-2]': 'Hibernating',
r'[1-2][3-4]': 'At risk',
r'[1-2]5': 'Can\'t loose',
r'3[1-2]': 'About to sleep',
r'33': 'Need attention',
# labels positifs
r'[3-4][4-5]': 'Loyal customers',
r'41': 'Promising',
r'51': 'New customers',
r'[4-5][2-3]': 'Potential loyalists',
r'5[4-5]': 'Champions'
}
rfm['Segment'] = rfm['R'].map(str) + rfm['F'].map(str)
rfm['Segment'] = rfm['Segment'].replace(label_map, regex=True)
rfm.head(20)
| CustomerID | Récence | Fréquence | Montant | R | F | M | RFM_Score | Segment | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0000f46a3911fa3c0805444483337064 | 296 | 1 | 86.220 | 1 | 1 | 2 | 112 | Hibernating |
| 1 | 0000f6ccb0745a6a4b88665a16c9f078 | 80 | 1 | 43.620 | 4 | 1 | 1 | 411 | Promising |
| 2 | 0004aac84e0df4da2b147fca70cf8255 | 47 | 1 | 196.890 | 4 | 1 | 4 | 414 | Promising |
| 3 | 0005e1862207bf6ccc02e4228effd9a0 | 301 | 1 | 150.120 | 1 | 1 | 4 | 114 | Hibernating |
| 4 | 0006fdc98a402fceb4eb0ee528f6a8d4 | 166 | 1 | 29.000 | 2 | 1 | 1 | 211 | Hibernating |
| 5 | 00082cbe03e478190aadbea78542e933 | 42 | 1 | 126.260 | 4 | 1 | 3 | 413 | Promising |
| 6 | 000a5ad9c4601d2bbdd9ed765d5213b3 | 142 | 1 | 91.280 | 3 | 1 | 3 | 313 | About to sleep |
| 7 | 000bfa1d2f1a41876493be685390d6d3 | 93 | 2 | 93.700 | 3 | 2 | 3 | 323 | About to sleep |
| 8 | 000c8bdb58a29e7115cfc257230fb21b | 19 | 1 | 29.000 | 5 | 1 | 1 | 511 | New customers |
| 9 | 000de6019bb59f34c099a907c151d855 | 136 | 2 | 514.880 | 3 | 2 | 5 | 325 | About to sleep |
| 10 | 0010a452c6d13139e50b57f19f52e04e | 173 | 1 | 325.930 | 2 | 1 | 5 | 215 | Hibernating |
| 11 | 001147e649a7b1afd577e873841632dd | 122 | 2 | 424.320 | 3 | 2 | 5 | 325 | About to sleep |
| 12 | 00115fc7123b5310cf6d3a3aa932699e | 344 | 1 | 76.110 | 1 | 1 | 2 | 112 | Hibernating |
| 13 | 0011805441c0d1b68b48002f1d005526 | 251 | 1 | 297.140 | 1 | 1 | 5 | 115 | Hibernating |
| 14 | 0011857aff0e5871ce5eb429f21cdaf5 | 186 | 1 | 192.830 | 2 | 1 | 4 | 214 | Hibernating |
| 15 | 0011c98589159d6149979563c504cb21 | 148 | 1 | 117.940 | 3 | 1 | 3 | 313 | About to sleep |
| 16 | 0012929d977a8d7280bb277c1e5f589d | 75 | 1 | 155.650 | 4 | 1 | 4 | 414 | Promising |
| 17 | 00191a9719ef48ebb5860b130347bf33 | 256 | 1 | 58.860 | 1 | 1 | 2 | 112 | Hibernating |
| 18 | 001926cef41060fae572e2e7b30bd2a4 | 136 | 2 | 182.420 | 3 | 2 | 4 | 324 | About to sleep |
| 19 | 001a2bf0e46c684031af91fb2bce149d | 185 | 1 | 36.730 | 2 | 1 | 1 | 211 | Hibernating |
Conclusions
Quels comportements sont les plus fréquents ?
# Les types de comportement et leurs valeurs moyennes
rfm_segments_type = rfm.groupby('Segment').agg({
'Récence': 'mean',
'Fréquence': 'mean',
'Montant': ['mean', 'count']
}).round(1)
rfm_segments_type
| Récence | Fréquence | Montant | ||
|---|---|---|---|---|
| mean | mean | mean | count | |
| Segment | ||||
| About to sleep | 122.500 | 1.100 | 165.100 | 7865 |
| At risk | 237.900 | 3.700 | 501.200 | 447 |
| Can't loose | 224.400 | 8.500 | 413.200 | 21 |
| Champions | 26.100 | 4.800 | 567.500 | 117 |
| Hibernating | 236.900 | 1.100 | 159.600 | 15723 |
| Loyal customers | 91.800 | 4.700 | 596.900 | 232 |
| Need attention | 122.300 | 3.000 | 496.200 | 183 |
| New customers | 24.100 | 1.000 | 137.200 | 7261 |
| Potential loyalists | 43.700 | 2.200 | 342.100 | 2144 |
| Promising | 62.600 | 1.000 | 149.400 | 6604 |
Les clients les plus nombreux semblent ceux "en hibernation" :
# les segments les plus représentatifs
segments_counts = rfm['Segment'].value_counts().sort_values(ascending=False)
plt.figure(figsize=(8, 8))
explode = [0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
segments_counts.plot.pie(explode=explode,
shadow=True, autopct='%1.1f%%')
plt.title("Les segments")
plt.tight_layout()
en hibernation - 38,7%.Can't loose, Champions, Need attention, Loyal customersNew customers, About to sleep et Promising.Conclusion
La segmentation RFM a permis de classer les attitudes clients dans toutes les catégories proposées. Dans un souci de rester cohérent et ne pas brouiller les résultats, le nombre de clusters final ne devrait pas dépasser ce chiffre.
from sklearn import preprocessing, cluster, metrics
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn import decomposition
from sklearn.metrics import silhouette_samples, silhouette_score
from yellowbrick.cluster import KElbowVisualizer, SilhouetteVisualizer
from yellowbrick.cluster import InterclusterDistance
La suite de l'analyse se fera en plusieurs étapes :
Afin de trouver le nombre optimal de clusters k, nous allons nous appuyer sur les métriques suivantes (et leurs visualiseurs) :
Notre jeu de données rfm contient des données de type object. Nous allons devoir effectuer un prétraitement afin de transfomer le dataset en un format propice au machine learning :
rfm.head()
| CustomerID | Récence | Fréquence | Montant | R | F | M | RFM_Score | Segment | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0000f46a3911fa3c0805444483337064 | 296 | 1 | 86.220 | 1 | 1 | 2 | 112 | Hibernating |
| 1 | 0000f6ccb0745a6a4b88665a16c9f078 | 80 | 1 | 43.620 | 4 | 1 | 1 | 411 | Promising |
| 2 | 0004aac84e0df4da2b147fca70cf8255 | 47 | 1 | 196.890 | 4 | 1 | 4 | 414 | Promising |
| 3 | 0005e1862207bf6ccc02e4228effd9a0 | 301 | 1 | 150.120 | 1 | 1 | 4 | 114 | Hibernating |
| 4 | 0006fdc98a402fceb4eb0ee528f6a8d4 | 166 | 1 | 29.000 | 2 | 1 | 1 | 211 | Hibernating |
# Sélection de variables cibles
rfm_all = rfm[['Récence', 'Fréquence', 'Montant', 'R', 'F']]
rfm_all.head()
| Récence | Fréquence | Montant | R | F | |
|---|---|---|---|---|---|
| 0 | 296 | 1 | 86.220 | 1 | 1 |
| 1 | 80 | 1 | 43.620 | 4 | 1 |
| 2 | 47 | 1 | 196.890 | 4 | 1 |
| 3 | 301 | 1 | 150.120 | 1 | 1 |
| 4 | 166 | 1 | 29.000 | 2 | 1 |
rfm_all['Segment'] = rfm_all['R'].map(str) + rfm['F'].map(str)
rfm_all['Segment'] = rfm_all['Segment'].replace(label_map, regex=True)
rfm_all.head()
/tmp/ipykernel_390644/2814964321.py:1: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy rfm_all['Segment'] = rfm_all['R'].map(str) + rfm['F'].map(str) /tmp/ipykernel_390644/2814964321.py:2: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy rfm_all['Segment'] = rfm_all['Segment'].replace(label_map, regex=True)
| Récence | Fréquence | Montant | R | F | Segment | |
|---|---|---|---|---|---|---|
| 0 | 296 | 1 | 86.220 | 1 | 1 | Hibernating |
| 1 | 80 | 1 | 43.620 | 4 | 1 | Promising |
| 2 | 47 | 1 | 196.890 | 4 | 1 | Promising |
| 3 | 301 | 1 | 150.120 | 1 | 1 | Hibernating |
| 4 | 166 | 1 | 29.000 | 2 | 1 | Hibernating |
rfm_only = rfm[['Récence', 'Fréquence', 'Montant']]
rfm_only.head()
| Récence | Fréquence | Montant | |
|---|---|---|---|
| 0 | 296 | 1 | 86.220 |
| 1 | 80 | 1 | 43.620 |
| 2 | 47 | 1 | 196.890 |
| 3 | 301 | 1 | 150.120 |
| 4 | 166 | 1 | 29.000 |
# Normalisation des données
scaler = StandardScaler()
rfm_scaled = pd.DataFrame(scaler.fit_transform(
rfm_only), columns=rfm_only.columns)
rfm_scaled.head()
| Récence | Fréquence | Montant | |
|---|---|---|---|
| 0 | 1.682 | -0.331 | -0.445 |
| 1 | -0.591 | -0.331 | -0.661 |
| 2 | -0.938 | -0.331 | 0.118 |
| 3 | 1.735 | -0.331 | -0.120 |
| 4 | 0.314 | -0.331 | -0.735 |
L'objectif d'apprentissage non supervisé est de laisser la machine classer les données selon leurs ressemblances.
K-Means nous permettra de trouver des échantillons dont les caractéristiques sont très proches de celles des autres échantillons. Cela se fait en deux étapes :
K-Means cherche la position des centres qui minimise la distance entre les points d'un cluster et le centre de ce dernier (la fonction coût - Inertia).
Eléments-clés
Le seul paramètre nécessaire qui doit être renseigné au préalable est le nombre de clusters (n_clusters).
Il est possible également de définir les paramètres suivants :
init - ici random - le positionnement des centroïdes initiales sera aléatoiren_init - précise combien de fois l'algorithme doit "faire le tour" des donnéesAfin de trouver un nombre de clusters optimal, nous allons faire appel à la technique du coude. La courbe ainsi obtenue permet de tracer l'évolution du coût de notre modèle en fonction du nombre de clusters.
# Tracer l'évolution pour 12 clusters maximum
def score_elbow(df_scaled):
model = cluster.KMeans(random_state=1)
visualizer = KElbowVisualizer(model, k=(1, 12))
plt.figure(figsize=(12, 6))
visualizer.fit(df_scaled) # Fit the data to the visualizer
visualizer.poof() # Draw/show/poof the data
plt.show()
score_elbow(rfm_scaled)
La courbe décroît avec le nombre de clusters. La zone du coude est le point représentant le nombre optimal de clusters, elle indique qu'il faut en privilégier 4.
model = KMeans(n_clusters=4, n_init=10, random_state=1)
model.fit(rfm_scaled)
KMeans(n_clusters=4, random_state=1)
# Affichage des coordonnées des centroïdes (pour 12 clusters)
model.cluster_centers_
array([[ 1.03349231, -0.17480781, -0.23973585],
[-0.72664086, -0.1783517 , -0.23776575],
[-0.09457169, 4.27572657, 1.51879683],
[-0.02069762, 0.52308167, 2.9044782 ]])
# La somme des distances entre les points d'un cluster et le centroïde (inertia)
# model.inertia_
# Attribution de cluster
clusters = model.predict(rfm_scaled)
len(set(clusters))
4
Nous allons à présent afficher les clusters obtenus sur deux dimensions :
pca = PCA(n_components=2)
pca.fit(rfm_scaled)
PCA(n_components=2)
# Le pourcentage de variance expliqué par les deux premières composantes
pca.explained_variance_ratio_.cumsum()
array([0.46359949, 0.79683521])
Presque 80% de la variance totale expliquée, c'est un bon résultat.
# Transformation PCA : affichage des deux composantes principales
rfm_transf = pd.DataFrame(pca.transform(rfm_scaled), columns=['PC1', 'PC2'])
# Merge avec la table "clusters" pour avoir l'info sur le cluster attribué
rfm_transf['cluster'] = clusters
rfm_transf
| PC1 | PC2 | cluster | |
|---|---|---|---|
| 0 | -0.594 | 1.666 | 0 |
| 1 | -0.685 | -0.610 | 1 |
| 2 | -0.125 | -0.943 | 1 |
| 3 | -0.366 | 1.725 | 0 |
| 4 | -0.762 | 0.294 | 0 |
| ... | ... | ... | ... |
| 40592 | -0.545 | -0.796 | 1 |
| 40593 | -0.560 | -1.301 | 1 |
| 40594 | -0.521 | -1.227 | 1 |
| 40595 | -0.509 | 1.995 | 0 |
| 40596 | -0.632 | 1.108 | 0 |
40597 rows × 3 columns
# Affichage des centroïdes en 2 dimensions
reduced_centers = pca.transform(model.cluster_centers_)
# reduced_centers
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/base.py:450: UserWarning: X does not have valid feature names, but PCA was fitted with feature names warnings.warn(
plt.figure(figsize=(16, 8))
plt.scatter(rfm_transf[rfm_transf['cluster'] == 0].loc[:, 'PC1'],
rfm_transf[rfm_transf['cluster'] == 0].loc[:, 'PC2'], color='red')
plt.scatter(rfm_transf[rfm_transf['cluster'] == 1].loc[:, 'PC1'],
rfm_transf[rfm_transf['cluster'] == 1].loc[:, 'PC2'], color='blue')
plt.scatter(rfm_transf[rfm_transf['cluster'] == 2].loc[:, 'PC1'],
rfm_transf[rfm_transf['cluster'] == 2].loc[:, 'PC2'], color='cyan')
plt.scatter(rfm_transf[rfm_transf['cluster'] == 3].loc[:, 'PC1'],
rfm_transf[rfm_transf['cluster'] == 3].loc[:, 'PC2'], color='orange')
#plt.scatter(rfm_transf[rfm_transf['cluster'] == 4].loc[:, 'PC1'], rfm_transf[rfm_transf['cluster'] == 4].loc[:, 'PC2'], color='cyan')
#plt.scatter(rfm_transf[rfm_transf['cluster'] == 5].loc[:, 'PC1'], rfm_transf[rfm_transf['cluster'] == 5].loc[:, 'PC2'], color='yellow')
#plt.scatter(rfm_transf[rfm_transf['cluster'] == 6].loc[:, 'PC1'], rfm_transf[rfm_transf['cluster'] == 6].loc[:, 'PC2'], color='brown')
plt.scatter(reduced_centers[:4, 0], reduced_centers[:4,
1], color='black', marker='x', s=200)
plt.xlabel("PC1")
plt.ylabel("PC2")
plt.show()
L'affichage des clusters en deux dimensions permet d'identifier 4 clusters distincts avec leurs centroïdes. Le cluster cyan semble être très dispersé.
Vérifions les résultats pour le score silhouette.
L'analyse de silhouette peut être utilisée pour évaluer la densité et la séparation entre les clusters. Le score est la moyenne du coefficient de silhouette pour tous les points - il s'agit de la différence entre la distance intra-cluster moyenne et la distance moyenne du cluster le plus proche pour chaque échantillon, normalisée par la valeur maximale.
Cela produit un score compris entre -1 et +1, où :
def score_silh(df):
silhouettes = []
# Entre 2 et 10 clusters sont à examiner
for k in range(2, 10):
cls = cluster.KMeans(n_clusters=k, n_init=4, random_state=1)
cls.fit(df)
score = metrics.silhouette_score(df, cls.labels_)
silhouettes.append(score)
plt.figure(figsize=(10, 6))
plt.plot(range(2, 10), silhouettes, marker='o')
score_silh(rfm_scaled)
Le coefficient de silhouette change avec le nombre de clusters. Il faut privilégier les clusters pour lesquels le coefficient est le plus élevé : 4 ou 5.
La méthode du coude suggère un nombre optimal de 4 clusters. Le score silhouette indique également 4 ou 5 sinon 9 clusters. Neuf clusters est un chiffre trop élevé dans le cadre de la segmentation. Vérifions le résultat :
def kmeans_make_cls(df, df_transf, cls1, cls2):
fig = plt.figure(figsize=(14, 8))
cls = cluster.KMeans(n_clusters=cls1, random_state=1)
cls.fit(df)
ax = fig.add_subplot(121)
ax.scatter(df_transf[:, 0], df_transf[:, 1], c=cls.labels_)
ax.set_facecolor("lightgrey")
cls_bis = cluster.KMeans(n_clusters=cls2, random_state=1)
cls_bis.fit(df)
ax = fig.add_subplot(122)
ax.scatter(df_transf[:, 0], df_transf[:, 1], c=cls_bis.labels_)
ax.set_facecolor("lightgrey")
plt.show()
# Transformation pca
pca.fit(rfm_scaled)
rfm_transf = pca.transform(rfm_scaled)
kmeans_make_cls(rfm_scaled, rfm_transf, cls1=9, cls2=5)
Pour mieux se rendre compte de la densité et la séparation entre clusters, nous allons utiliser l'outil Silhouette Visualizer. Nous allons afficher les résultats pour un nombre de clusters optimal indiqué par les méthodes utlisées ci-dessus :
def silh_vizualizer(df_scaled, cls):
plt.figure(figsize=(8, 6))
model = KMeans(cls, random_state=1)
visualizer = SilhouetteVisualizer(model)
# Fit the data to the visualizer
visualizer.fit(df_scaled)
visualizer.poof()
plt.show()
silh_vizualizer(rfm_scaled, cls=4)
Conclusions pour k=4:
La ligne verticale pointillée rouge indique le score de silhouette moyen pour toutes les observations. Les graphiques contiennent des silhouettes très différentes :
silh_vizualizer(rfm_scaled, cls=5)
Conclusions pour k=5 :
silh_vizualizer(rfm_scaled, cls=9)
A présent nous allons afficher les distances entre les clusters :
# Intercluster distance Map with best k
plt.figure(figsize=(12, 6))
distance_visualizer = InterclusterDistance(KMeans(n_clusters=4))
distance_visualizer.fit(rfm_scaled)
distance_visualizer.show()
plt.show()
Sur cette projection en 2D, on remarque que les différents clusters sont très bien séparés sur les 2 premières composantes principales. Le clustering semble donc performant.
# Intercluster distance Map with best k
plt.figure(figsize=(12, 6))
distance_visualizer = InterclusterDistance(KMeans(n_clusters=5))
distance_visualizer.fit(rfm_scaled)
distance_visualizer.show()
plt.show()
Une moins bonne séparation de clusters sur le graphique. Nous observons un chevauchement entre les clusters 1 et 3.
Les deux valeurs de n_clusters semblent être la valeur optimale :
Essayons d'identifier les profils client :
# Best model k=5 :
best = KMeans(n_clusters=5, n_init=10, random_state=1)
best.fit(rfm_scaled)
# extraction des labels de clusters avec attribut labels_
cluster_labels = best.labels_
# création colonne labels clusters dans dataframe d'origine
rfm_stats = rfm_all.assign(Cluster=cluster_labels)
# calcul valeurs RFM moyennes et taille de chaque cluster
rfm_stats.groupby(['Cluster']).agg({
'Récence': 'mean',
'Fréquence': 'mean',
'Montant': 'mean',
'Segment': ['unique', 'count']}).round(0)
| Récence | Fréquence | Montant | Segment | ||
|---|---|---|---|---|---|
| mean | mean | mean | unique | count | |
| Cluster | |||||
| 0 | 119.000 | 2.000 | 252.000 | [About to sleep, Potential loyalists, Hibernat... | 4139 |
| 1 | 69.000 | 1.000 | 120.000 | [Promising, About to sleep, New customers] | 19991 |
| 2 | 238.000 | 1.000 | 121.000 | [Hibernating, About to sleep] | 13953 |
| 3 | 135.000 | 5.000 | 558.000 | [At risk, Loyal customers, Champions, Can't lo... | 573 |
| 4 | 136.000 | 1.000 | 822.000 | [Potential loyalists, Hibernating, About to sl... | 1941 |
# Best model k=4 :
best = KMeans(n_clusters=4, n_init=10, random_state=1)
best.fit(rfm_scaled)
# extraction des labels de clusters avec attribut labels_
cluster_labels = best.labels_
# création colonne labels clusters dans dataframe d'origine
rfm_stats = rfm_all.assign(Cluster=cluster_labels)
# calcul valeurs RFM moyennes et taille de chaque cluster
rfm_stats.groupby(['Cluster']).agg({
'Récence': 'mean',
'Fréquence': 'mean',
'Montant': 'mean',
'Segment': ['unique', 'count']}).round(0)
| Récence | Fréquence | Montant | Segment | ||
|---|---|---|---|---|---|
| mean | mean | mean | unique | count | |
| Cluster | |||||
| 0 | 234.000 | 1.000 | 127.000 | [Hibernating, About to sleep, At risk] | 15357 |
| 1 | 67.000 | 1.000 | 127.000 | [Promising, About to sleep, New customers, Pot... | 21612 |
| 2 | 127.000 | 4.000 | 473.000 | [At risk, Potential loyalists, Loyal customers... | 1237 |
| 3 | 134.000 | 2.000 | 746.000 | [About to sleep, Potential loyalists, Hibernat... | 2391 |
Conclusions
Plusieurs conclusions :
Nous constatons un meilleur découpage avec n_clusters=4. Les clusters sont plus diversifiés en termes de taille.
df_rfm_stats = pd.DataFrame(rfm_stats['Cluster'].value_counts())
sns.set(style='whitegrid')
facecolor = '#eaeaf2'
fig, ax = plt.subplots(figsize=(12, 6), facecolor=facecolor)
sns.barplot(data=df_rfm_stats, y=df_rfm_stats.Cluster.values,
x=df_rfm_stats.Cluster.index, linewidth=2.5, palette='Set2')
plt.title('Clusters KMeans k=4')
plt.ylabel('Nombre de clients')
plt.xlabel('Cluster')
plt.grid()
L’analyse du graphique permet de visualiser la volumétrie de chaque cluster :
A présent nous allons afficher le graphe des coordonnées parallèles qui nous apportera des informations supplémentaires sur les clusters :
from pandas.plotting import parallel_coordinates
palette = sns.color_palette("bright", 10)
def addAlpha(colour, alpha):
'''Add an alpha to the RGB colour'''
return (colour[0], colour[1], colour[2], alpha)
def display_parallel_coordinates(df, num_clusters):
'''Display a parallel coordinates plot for the clusters in df'''
# Select data points for individual clusters
cluster_points = []
for i in range(num_clusters):
cluster_points.append(df[df.cluster == i])
# Create the plot
fig = plt.figure(figsize=(12, 15))
title = fig.suptitle(
"Parallel Coordinates Plot for the Clusters", fontsize=18)
fig.subplots_adjust(top=0.95, wspace=0)
# Display one plot for each cluster, with the lines for the main cluster appearing over the lines for the other clusters
for i in range(num_clusters):
plt.subplot(num_clusters, 1, i+1)
for j, c in enumerate(cluster_points):
if i != j:
pc = parallel_coordinates(
c, 'cluster', color=[addAlpha(palette[j], 0.2)])
pc = parallel_coordinates(cluster_points[i], 'cluster', color=[
addAlpha(palette[i], 0.5)])
# Stagger the axes
ax = plt.gca()
for tick in ax.xaxis.get_major_ticks()[1::2]:
tick.set_pad(20)
# Add the cluster number to the original scaled data
rfm_clustered = pd.DataFrame(
rfm_scaled, index=rfm_only.index, columns=rfm_only.columns)
rfm_clustered["cluster"] = clusters
# Display parallel coordinates plots, one for each cluster
display_parallel_coordinates(rfm_clustered, 4)
Les données ne sont pas très lisibles, étant donnée la densité des lignes (chaque ligne correspond à un enregistrement). Les clusters semblent assez similaires. Essayons de calculer la moyenne :
def display_parallel_coordinates_centroids(df, num_clusters):
'''Display a parallel coordinates plot for the centroids in df'''
# Create the plot
fig = plt.figure(figsize=(12, 5))
title = fig.suptitle(
"Parallel Coordinates plot for the Centroids", fontsize=18)
fig.subplots_adjust(top=0.9, wspace=0)
# Draw the chart
parallel_coordinates(df, 'cluster', color=palette)
# Stagger the axes
ax = plt.gca()
for tick in ax.xaxis.get_major_ticks()[1::2]:
tick.set_pad(20)
# Create a data frame containing our centroids
centroids = pd.DataFrame(model.cluster_centers_, columns=rfm_only.columns)
centroids['cluster'] = centroids.index
display_parallel_coordinates_centroids(centroids, 4)
Conclusions
Voilà les caractéristiques des clusters respectifs :
Cluster 0 : en bleu
HibernatingCluster 1 : en orange
Promising, About to sleepCluster 2 : en vert
Loyal customers, Potential loyalistsCluster 3 : en rouge
About to sleep, Potential loyalistsCréation du jeu de données
avis = data.loc[(data['order_purchase_timestamp'] > start) &
(data['order_purchase_timestamp'] < end)]
avis = avis[['customer_unique_id', 'review_score']]
avis.rename({'customer_unique_id': 'CustomerID',
'review_score': 'Avis'}, axis=1, inplace=True)
avis.head()
| CustomerID | Avis | |
|---|---|---|
| 0 | 7c396fd4830fd04220f754e42b4e5bff | 4 |
| 1 | 7c396fd4830fd04220f754e42b4e5bff | 4 |
| 2 | 7c396fd4830fd04220f754e42b4e5bff | 4 |
| 3 | 3a51803cc0d012c3b5dc8b7528cb05f7 | 4 |
| 4 | ef0996a1a279c26e7ecbd737be23d235 | 5 |
print(avis.shape)
(50927, 2)
rfm_avis = avis.merge(rfm, on='CustomerID')
rfm_avis.head()
| CustomerID | Avis | Récence | Fréquence | Montant | R | F | M | RFM_Score | Segment | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7c396fd4830fd04220f754e42b4e5bff | 4 | 90 | 4 | 82.820 | 4 | 4 | 2 | 442 | Loyal customers |
| 1 | 7c396fd4830fd04220f754e42b4e5bff | 4 | 90 | 4 | 82.820 | 4 | 4 | 2 | 442 | Loyal customers |
| 2 | 7c396fd4830fd04220f754e42b4e5bff | 4 | 90 | 4 | 82.820 | 4 | 4 | 2 | 442 | Loyal customers |
| 3 | 7c396fd4830fd04220f754e42b4e5bff | 5 | 90 | 4 | 82.820 | 4 | 4 | 2 | 442 | Loyal customers |
| 4 | 3a51803cc0d012c3b5dc8b7528cb05f7 | 4 | 138 | 1 | 37.770 | 3 | 1 | 1 | 311 | About to sleep |
rfm_avis.shape
(48851, 10)
rfm_avis = rfm_avis[['Avis', 'Récence', 'Fréquence', 'Montant']]
rfm_avis.head()
| Avis | Récence | Fréquence | Montant | |
|---|---|---|---|---|
| 0 | 4 | 90 | 4 | 82.820 |
| 1 | 4 | 90 | 4 | 82.820 |
| 2 | 4 | 90 | 4 | 82.820 |
| 3 | 5 | 90 | 4 | 82.820 |
| 4 | 4 | 138 | 1 | 37.770 |
Normalisation
# Normalisation des données
scaler = StandardScaler()
rfm_avis_scaled = pd.DataFrame(
scaler.fit_transform(rfm_avis), columns=rfm_avis.columns)
rfm_avis_scaled.head()
| Avis | Récence | Fréquence | Montant | |
|---|---|---|---|---|
| 0 | -0.099 | -0.483 | 2.187 | -0.539 |
| 1 | -0.099 | -0.483 | 2.187 | -0.539 |
| 2 | -0.099 | -0.483 | 2.187 | -0.539 |
| 3 | 0.671 | -0.483 | 2.187 | -0.539 |
| 4 | -0.099 | 0.023 | -0.455 | -0.726 |
score_elbow(rfm_avis_scaled)
La méthode du coude indique qu'il faut privilégier entre 2 et 6 clusters. Nous allons réaliser une segmentation avec 4.
model = KMeans(n_clusters=4, n_init=10, random_state=1)
model.fit(rfm_avis_scaled)
KMeans(n_clusters=4, random_state=1)
# Affichage des coordonnées des centroïdes (pour 12 clusters)
model.cluster_centers_
array([[ 0.41264393, -0.70581174, -0.2700898 , -0.28211319],
[-1.93281553, -0.14891052, -0.05032929, -0.0563661 ],
[-0.10231281, -0.08800968, 2.02175553, 2.1567808 ],
[ 0.36561873, 1.08459572, -0.23541792, -0.25828947]])
# Attribution de cluster
clusters = model.predict(rfm_avis_scaled)
len(set(clusters))
4
Nous allons à présent représenter les clusters obtenus sur deux dimensions :
pca = PCA(n_components=2)
pca.fit(rfm_avis_scaled)
PCA(n_components=2)
# Le pourcentage de variance expliqué par les deux premières composantes
pca.explained_variance_ratio_.cumsum()
array([0.3725056 , 0.62955819])
Presque 63% de variance expliqué.
# Affichage des centroïdes en 2 dimensions
reduced_centers = pca.transform(model.cluster_centers_)
# reduced_centers
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/base.py:450: UserWarning: X does not have valid feature names, but PCA was fitted with feature names warnings.warn(
# Transformation PCA : affichage des deux composantes
avis_transf = pd.DataFrame(pca.transform(
rfm_avis_scaled), columns=['PC1', 'PC2'])
# Merge avec la table "clusters" pour avoir l'info sur le cluster attribué
avis_transf['cluster'] = clusters
avis_transf.head()
| PC1 | PC2 | cluster | |
|---|---|---|---|
| 0 | 1.174 | -0.189 | 0 |
| 1 | 1.174 | -0.189 | 0 |
| 2 | 1.174 | -0.189 | 0 |
| 3 | 0.951 | 0.202 | 0 |
| 4 | -0.770 | -0.207 | 0 |
plt.figure(figsize=(10, 6))
plt.scatter(avis_transf[avis_transf['cluster'] == 0].loc[:, 'PC1'],
avis_transf[avis_transf['cluster'] == 0].loc[:, 'PC2'], color='red')
plt.scatter(avis_transf[avis_transf['cluster'] == 1].loc[:, 'PC1'],
avis_transf[avis_transf['cluster'] == 1].loc[:, 'PC2'], color='blue')
plt.scatter(avis_transf[avis_transf['cluster'] == 2].loc[:, 'PC1'],
avis_transf[avis_transf['cluster'] == 2].loc[:, 'PC2'], color='yellow')
plt.scatter(avis_transf[avis_transf['cluster'] == 3].loc[:, 'PC1'],
avis_transf[avis_transf['cluster'] == 3].loc[:, 'PC2'], color='orange')
#plt.scatter(avis_transf[avis_transf['cluster'] == 4].loc[:, 'PC1'], avis_transf[avis_transf['cluster'] == 4].loc[:, 'PC2'], color='cyan')
#plt.scatter(avis_transf[avis_transf['cluster'] == 5].loc[:, 'PC1'], avis_transf[avis_transf['cluster'] == 5].loc[:, 'PC2'], color='magenta')
#plt.scatter(avis_transf[avis_transf['cluster'] == 6].loc[:, 'PC1'], avis_transf[avis_transf['cluster'] == 6].loc[:, 'PC2'], color='brown')
plt.scatter(reduced_centers[:4, 0], reduced_centers[:4,
1], color='black', marker='x', s=200)
plt.xlabel("PC1")
plt.ylabel("PC2")
plt.show()
Quatre clusters se dessinent sur le graphique, avec les centroïdes bien attribués. Il est difficile de faire une distinction nette, pour cela nous allons visualiser les clusters avec le silhouette vizualizer (cf plus bas).
score_silh(rfm_avis_scaled)
Le coefficient de silhouette décroît avec le nombre de clusters. Il faut privilégier les clusters pour lesquels le coefficient est le plus élevé : le 3 ou le 5.
La méthode du coude suggère un nombre optimal de 4 clusters. Le score silhouette indique 5 ou 6/7 clusters. Nous allons visualiser tous les scénarios :
pca.fit(rfm_avis_scaled)
avis_transf = pca.transform(rfm_avis_scaled)
kmeans_make_cls(rfm_avis_scaled, avis_transf, cls1=6, cls2=5)
silh_vizualizer(rfm_avis_scaled, cls=4)
Conclusions pour k=4 :
A présent nous allons afficher les distances entre 4 clusters :
# Intercluster distance Map with best k
plt.figure(figsize=(12, 6))
distance_visualizer = InterclusterDistance(KMeans(n_clusters=4))
distance_visualizer.fit(rfm_avis_scaled)
distance_visualizer.show()
plt.show()
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/manifold/_mds.py:517: UserWarning: The MDS API has changed. ``fit`` now constructs an dissimilarity matrix from data. To use a custom dissimilarity matrix, set ``dissimilarity='precomputed'``. warnings.warn(
Sur cette projection en 2D, on remarque que les différents clusters sont bien séparés sur les 2 premières composantes principales. Le clustering semble donc performant.
Vérifions la séparation des clusters pour le n_clusters à 5 :
silh_vizualizer(rfm_avis_scaled, cls=5)
Conclusions pour k=5 :
# Intercluster distance Map with best k
plt.figure(figsize=(12, 6))
distance_visualizer = InterclusterDistance(KMeans(n_clusters=5))
distance_visualizer.fit(rfm_avis_scaled)
distance_visualizer.show()
plt.show()
Pas de chevauchements pour 5 clusters. Les clusters sont très bien séparés.
silh_vizualizer(rfm_avis_scaled, cls=6)
Conclusions pour k=6 :
Le nombre d'erreurs étant assez élevé, faisons une dernière vérification avec k=3 :
silh_vizualizer(rfm_avis_scaled, cls=3)
Conclusions pour k= 3 :
La valeur de 4 ou 5 pour n_clusters semble être la valeur optimale :
Regardons les valeurs moyennes par cluster :
# Ajout de la colonne `Segments`
rfm_avis['Segments'] = rfm['Segment']
# Best model k=4 :
best = KMeans(n_clusters=4, n_init=10, random_state=1)
best.fit(rfm_avis_scaled)
# extraction des labels de clusters avec attribut labels_
cluster_labels = best.labels_
# création colonne labels clusters dans dataframe d'origine
rfm_avis_stats = rfm_avis.assign(Cluster=cluster_labels)
# calcul valeurs RFM moyennes et taille de chaque cluster
rfm_avis_stats.groupby(['Cluster']).agg({
'Récence': 'mean',
'Fréquence': 'mean',
'Montant': 'mean',
'Segments': 'unique',
'Avis': ['mean', 'count']}).round(0)
| Récence | Fréquence | Montant | Segments | Avis | ||
|---|---|---|---|---|---|---|
| mean | mean | mean | unique | mean | count | |
| Cluster | ||||||
| 0 | 69.000 | 1.000 | 145.000 | [Hibernating, Promising, About to sleep, New c... | 5.000 | 21467 |
| 1 | 122.000 | 1.000 | 199.000 | [Hibernating, About to sleep, Promising, New c... | 2.000 | 7216 |
| 2 | 127.000 | 4.000 | 734.000 | [Potential loyalists, Hibernating, New custome... | 4.000 | 4828 |
| 3 | 239.000 | 1.000 | 151.000 | [Hibernating, New customers, About to sleep, P... | 5.000 | 15340 |
# Best model k= 5 :
best = KMeans(n_clusters=5, n_init=10, random_state=1)
best.fit(rfm_avis_scaled)
# extraction des labels de clusters avec attribut labels_
cluster_labels = best.labels_
# création colonne labels clusters dans dataframe d'origine
rfm_avis_stats = rfm_avis.assign(Cluster=cluster_labels)
# calcul valeurs RFM moyennes et taille de chaque cluster
rfm_avis_stats.groupby(['Cluster']).agg({
'Récence': 'mean',
'Fréquence': 'mean',
'Montant': 'mean',
'Segments': 'unique',
'Avis': ['mean', 'count']}).round(0)
| Récence | Fréquence | Montant | Segments | Avis | ||
|---|---|---|---|---|---|---|
| mean | mean | mean | unique | mean | count | |
| Cluster | ||||||
| 0 | 240.000 | 1.000 | 144.000 | [Hibernating, New customers, About to sleep, P... | 5.000 | 14843 |
| 1 | 110.000 | 1.000 | 178.000 | [About to sleep, Hibernating, Promising, New c... | 2.000 | 8100 |
| 2 | 138.000 | 6.000 | 380.000 | [Hibernating, Promising, About to sleep, New c... | 4.000 | 1808 |
| 3 | 128.000 | 3.000 | 863.000 | [Potential loyalists, Hibernating, New custome... | 4.000 | 3775 |
| 4 | 71.000 | 1.000 | 141.000 | [Hibernating, Promising, About to sleep, New c... | 5.000 | 20325 |
Conclusions
Plusieurs conclusions :
Nous constatons un meilleur découpage avec 5 clusters.
df_rfm_stats_avis = pd.DataFrame(rfm_avis_stats['Cluster'].value_counts())
sns.set(style='whitegrid')
facecolor = '#eaeaf2'
fig, ax = plt.subplots(figsize=(12, 6), facecolor=facecolor)
sns.barplot(data=df_rfm_stats_avis, y=df_rfm_stats_avis.Cluster.values,
x=df_rfm_stats_avis.Cluster.index, linewidth=2.5, palette='Set2')
plt.title('Clusters KMeans k=5')
plt.ylabel('Nombre de clients')
plt.xlabel('Cluster')
plt.grid()
L’analyse du graphique permet de visualiser la volumétrie de chaque cluster :
A présent nous allons afficher le graphe des coordonnées parallèles qui nous apportera des informations supplémentaires sur les clusters identifiés :
# Récupération des labels du dernier best fit pour n_clusters = 5
clusters_check = np.array(cluster_labels)
np.unique(clusters_check)
array([0, 1, 2, 3, 4], dtype=int32)
# Add the cluster number to the original scaled data
rfm_avis_clustered = pd.DataFrame(
rfm_avis_scaled, index=rfm_avis.index, columns=rfm_avis.columns)
rfm_avis_clustered["cluster"] = cluster_labels
rfm_avis_clustered.drop(rfm_avis_clustered[['Segments']], axis=1, inplace=True)
rfm_avis_clustered.head()
| Avis | Récence | Fréquence | Montant | cluster | |
|---|---|---|---|---|---|
| 0 | -0.099 | -0.483 | 2.187 | -0.539 | 2 |
| 1 | -0.099 | -0.483 | 2.187 | -0.539 | 2 |
| 2 | -0.099 | -0.483 | 2.187 | -0.539 | 2 |
| 3 | 0.671 | -0.483 | 2.187 | -0.539 | 2 |
| 4 | -0.099 | 0.023 | -0.455 | -0.726 | 4 |
# Display parallel coordinates plots, one for each cluster
display_parallel_coordinates(rfm_avis_clustered, 5)
Les données ne sont pas très lisibles, étant donnée la densité des lignes. Les clusters semblent assez similaires. Essayons de calculer la moyenne :
# Create a data frame containing our centroids
means = rfm_avis_clustered.groupby(by="cluster").mean()
display_parallel_coordinates_centroids(means.reset_index(), 5)
rfm_avis_stats.groupby(['Cluster']).agg({
'Récence': 'mean',
'Fréquence': 'mean',
'Montant': 'mean',
'Segments': 'unique',
'Avis': ['mean', 'count']}).round(0)
| Récence | Fréquence | Montant | Segments | Avis | ||
|---|---|---|---|---|---|---|
| mean | mean | mean | unique | mean | count | |
| Cluster | ||||||
| 0 | 240.000 | 1.000 | 144.000 | [Hibernating, New customers, About to sleep, P... | 5.000 | 14843 |
| 1 | 110.000 | 1.000 | 178.000 | [About to sleep, Hibernating, Promising, New c... | 2.000 | 8100 |
| 2 | 138.000 | 6.000 | 380.000 | [Hibernating, Promising, About to sleep, New c... | 4.000 | 1808 |
| 3 | 128.000 | 3.000 | 863.000 | [Potential loyalists, Hibernating, New custome... | 4.000 | 3775 |
| 4 | 71.000 | 1.000 | 141.000 | [Hibernating, Promising, About to sleep, New c... | 5.000 | 20325 |
Le graphe de coordonnées parallèles permet de mieux comprendre les caractéristiques des clusters :
Hibernating, New customers, About to sleepAbout to sleep, Hibernating, PromisingHibernating, New customers, About to sleepPotential loyalists, Hibernating, New customersHibernating, Promising, About to sleepLes clients les moins contents constituent une minorité. Les clients les plus contents sont les plus nombreux (plus de 64 000 personnes ont émis un avis favorable noté 5).
Il serait probablement intéressant de fusionner les types de comportements les plus proches pour avoir une meilleure visibilité de profils client.
Création du dataset
data.head()
| order_id | customer_id | order_status | order_purchase_timestamp | order_approved_at | order_delivered_carrier_date | order_delivered_customer_date | order_estimated_delivery_date | order_item_id | product_id | ... | payment_sequential | payment_type | payment_installments | payment_value | review_score | product_category_name | customer_unique_id | customer_zip_code_prefix | customer_city | customer_state | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | e481f51cbdc54678b7cc49136f2d6af7 | 9ef432eb6251297304e76186b10a928d | delivered | 2017-10-02 10:56:33 | 2017-10-02 11:07:15 | 2017-10-04 19:55:00 | 2017-10-10 21:25:13 | 2017-10-18 | 1 | 87285b34884572647811a353c7ac498a | ... | 1 | credit_card | 1 | 18.120 | 4 | housewares | 7c396fd4830fd04220f754e42b4e5bff | 3149 | sao paulo | SP |
| 1 | e481f51cbdc54678b7cc49136f2d6af7 | 9ef432eb6251297304e76186b10a928d | delivered | 2017-10-02 10:56:33 | 2017-10-02 11:07:15 | 2017-10-04 19:55:00 | 2017-10-10 21:25:13 | 2017-10-18 | 1 | 87285b34884572647811a353c7ac498a | ... | 3 | voucher | 1 | 2.000 | 4 | housewares | 7c396fd4830fd04220f754e42b4e5bff | 3149 | sao paulo | SP |
| 2 | e481f51cbdc54678b7cc49136f2d6af7 | 9ef432eb6251297304e76186b10a928d | delivered | 2017-10-02 10:56:33 | 2017-10-02 11:07:15 | 2017-10-04 19:55:00 | 2017-10-10 21:25:13 | 2017-10-18 | 1 | 87285b34884572647811a353c7ac498a | ... | 2 | voucher | 1 | 18.590 | 4 | housewares | 7c396fd4830fd04220f754e42b4e5bff | 3149 | sao paulo | SP |
| 3 | 128e10d95713541c87cd1a2e48201934 | a20e8105f23924cd00833fd87daa0831 | delivered | 2017-08-15 18:29:31 | 2017-08-15 20:05:16 | 2017-08-17 15:28:33 | 2017-08-18 14:44:43 | 2017-08-28 | 1 | 87285b34884572647811a353c7ac498a | ... | 1 | credit_card | 3 | 37.770 | 4 | housewares | 3a51803cc0d012c3b5dc8b7528cb05f7 | 3366 | sao paulo | SP |
| 4 | 0e7e841ddf8f8f2de2bad69267ecfbcf | 26c7ac168e1433912a51b924fbd34d34 | delivered | 2017-08-02 18:24:47 | 2017-08-02 18:43:15 | 2017-08-04 17:35:43 | 2017-08-07 18:30:01 | 2017-08-15 | 1 | 87285b34884572647811a353c7ac498a | ... | 1 | credit_card | 1 | 37.770 | 5 | housewares | ef0996a1a279c26e7ecbd737be23d235 | 2290 | sao paulo | SP |
5 rows × 24 columns
delay = data.loc[(data['order_purchase_timestamp'] > start) &
(data['order_purchase_timestamp'] < end)]
delay = delay[['customer_unique_id', 'order_purchase_timestamp',
'order_estimated_delivery_date', 'order_delivered_customer_date', 'review_score']]
delay.rename({'customer_unique_id': 'CustomerID',
'order_purchase_timestamp': 'Date_achat',
'order_estimated_delivery_date': 'Date_estimée',
'order_delivered_customer_date': 'Date_livraison',
'review_score': 'Avis'}, axis=1, inplace=True)
delay.head()
| CustomerID | Date_achat | Date_estimée | Date_livraison | Avis | |
|---|---|---|---|---|---|
| 0 | 7c396fd4830fd04220f754e42b4e5bff | 2017-10-02 10:56:33 | 2017-10-18 | 2017-10-10 21:25:13 | 4 |
| 1 | 7c396fd4830fd04220f754e42b4e5bff | 2017-10-02 10:56:33 | 2017-10-18 | 2017-10-10 21:25:13 | 4 |
| 2 | 7c396fd4830fd04220f754e42b4e5bff | 2017-10-02 10:56:33 | 2017-10-18 | 2017-10-10 21:25:13 | 4 |
| 3 | 3a51803cc0d012c3b5dc8b7528cb05f7 | 2017-08-15 18:29:31 | 2017-08-28 | 2017-08-18 14:44:43 | 4 |
| 4 | ef0996a1a279c26e7ecbd737be23d235 | 2017-08-02 18:24:47 | 2017-08-15 | 2017-08-07 18:30:01 | 5 |
delay['delta_achat_livraison'] = (
delay['Date_livraison'] - delay['Date_achat']).dt.days
delay['delta_livraison_estimée'] = (
delay['Date_estimée'] - delay['Date_achat']).dt.days
delay["Retard"] = (delay['Date_estimée'] - delay['Date_livraison']).dt.days
delay.head()
| CustomerID | Date_achat | Date_estimée | Date_livraison | Avis | delta_achat_livraison | delta_livraison_estimée | Retard | |
|---|---|---|---|---|---|---|---|---|
| 0 | 7c396fd4830fd04220f754e42b4e5bff | 2017-10-02 10:56:33 | 2017-10-18 | 2017-10-10 21:25:13 | 4 | 8 | 15 | 7 |
| 1 | 7c396fd4830fd04220f754e42b4e5bff | 2017-10-02 10:56:33 | 2017-10-18 | 2017-10-10 21:25:13 | 4 | 8 | 15 | 7 |
| 2 | 7c396fd4830fd04220f754e42b4e5bff | 2017-10-02 10:56:33 | 2017-10-18 | 2017-10-10 21:25:13 | 4 | 8 | 15 | 7 |
| 3 | 3a51803cc0d012c3b5dc8b7528cb05f7 | 2017-08-15 18:29:31 | 2017-08-28 | 2017-08-18 14:44:43 | 4 | 2 | 12 | 9 |
| 4 | ef0996a1a279c26e7ecbd737be23d235 | 2017-08-02 18:24:47 | 2017-08-15 | 2017-08-07 18:30:01 | 5 | 5 | 12 | 7 |
delay.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 50927 entries, 0 to 115607 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 CustomerID 50927 non-null object 1 Date_achat 50927 non-null datetime64[ns] 2 Date_estimée 50927 non-null datetime64[ns] 3 Date_livraison 50927 non-null datetime64[ns] 4 Avis 50927 non-null int64 5 delta_achat_livraison 50927 non-null int64 6 delta_livraison_estimée 50927 non-null int64 7 Retard 50927 non-null int64 dtypes: datetime64[ns](3), int64(4), object(1) memory usage: 3.5+ MB
delay.duplicated().sum()
8480
delay.drop_duplicates(inplace=True)
rfm_delay = delay.merge(rfm, on='CustomerID')
rfm_delay.head()
| CustomerID | Date_achat | Date_estimée | Date_livraison | Avis | delta_achat_livraison | delta_livraison_estimée | Retard | Récence | Fréquence | Montant | R | F | M | RFM_Score | Segment | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7c396fd4830fd04220f754e42b4e5bff | 2017-10-02 10:56:33 | 2017-10-18 | 2017-10-10 21:25:13 | 4 | 8 | 15 | 7 | 90 | 4 | 82.820 | 4 | 4 | 2 | 442 | Loyal customers |
| 1 | 7c396fd4830fd04220f754e42b4e5bff | 2017-09-04 11:26:38 | 2017-09-15 | 2017-09-05 19:20:20 | 5 | 1 | 10 | 9 | 90 | 4 | 82.820 | 4 | 4 | 2 | 442 | Loyal customers |
| 2 | 3a51803cc0d012c3b5dc8b7528cb05f7 | 2017-08-15 18:29:31 | 2017-08-28 | 2017-08-18 14:44:43 | 4 | 2 | 12 | 9 | 138 | 1 | 37.770 | 3 | 1 | 1 | 311 | About to sleep |
| 3 | ef0996a1a279c26e7ecbd737be23d235 | 2017-08-02 18:24:47 | 2017-08-15 | 2017-08-07 18:30:01 | 5 | 5 | 12 | 7 | 151 | 1 | 37.770 | 3 | 1 | 1 | 311 | About to sleep |
| 4 | e781fdcc107d13d865fc7698711cc572 | 2017-10-23 23:26:46 | 2017-11-13 | 2017-11-07 18:04:59 | 3 | 14 | 20 | 5 | 69 | 1 | 44.090 | 4 | 1 | 1 | 411 | Promising |
rfm_delay.drop(rfm_delay[['CustomerID', 'Segment', 'RFM_Score',
'Date_achat', 'Date_estimée', 'Date_livraison']], axis=1, inplace=True)
delay.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 42447 entries, 0 to 115607 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 CustomerID 42447 non-null object 1 Date_achat 42447 non-null datetime64[ns] 2 Date_estimée 42447 non-null datetime64[ns] 3 Date_livraison 42447 non-null datetime64[ns] 4 Avis 42447 non-null int64 5 delta_achat_livraison 42447 non-null int64 6 delta_livraison_estimée 42447 non-null int64 7 Retard 42447 non-null int64 dtypes: datetime64[ns](3), int64(4), object(1) memory usage: 2.9+ MB
Normalisation
# Normalisation des données
scaler = StandardScaler()
delay_scaled = pd.DataFrame(scaler.fit_transform(
rfm_delay), columns=rfm_delay.columns)
delay_scaled.head()
| Avis | delta_achat_livraison | delta_livraison_estimée | Retard | Récence | Fréquence | Montant | R | F | M | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | -0.145 | -0.472 | -1.213 | -0.446 | -0.483 | 3.840 | -0.475 | 0.694 | 4.558 | -0.732 |
| 1 | 0.655 | -1.208 | -1.865 | -0.242 | -0.483 | 3.840 | -0.475 | 0.694 | 4.558 | -0.732 |
| 2 | -0.145 | -1.103 | -1.604 | -0.242 | 0.023 | -0.357 | -0.697 | -0.011 | -0.395 | -1.437 |
| 3 | 0.655 | -0.788 | -1.604 | -0.446 | 0.160 | -0.357 | -0.697 | -0.011 | -0.395 | -1.437 |
| 4 | -0.945 | 0.159 | -0.561 | -0.649 | -0.705 | -0.357 | -0.666 | 0.694 | -0.395 | -1.437 |
score_elbow(delay_scaled)
La méthode du coude indique qu'il faut privilégier entre 3 et 6 clusters. Nous allons en appliquer 4.
model = KMeans(n_clusters=4, n_init=10)
model.fit(delay_scaled)
KMeans(n_clusters=4)
# Affichage des coordonnées des centroïdes (pour 12 clusters)
model.cluster_centers_
array([[ 0.11303357, -0.17154479, 0.18488388, 0.29711494, 1.04682974,
-0.2556095 , -0.22290809, -1.04115527, -0.2754241 , -0.16855666],
[ 0.18791444, -0.14346306, -0.18113738, 0.00664599, -0.70960263,
-0.27699037, -0.24829508, 0.70485259, -0.30065123, -0.19671123],
[-0.19515497, -0.11235614, 0.03496801, 0.13820118, -0.08827847,
1.92002384, 1.60819876, 0.05883312, 2.07417601, 1.18897478],
[-1.89285548, 2.55512466, 0.28248857, -2.25075111, -0.46227434,
-0.17968283, 0.04395033, 0.53020911, -0.18654942, 0.16798391]])
# Attribution de cluster
clusters = model.predict(delay_scaled)
len(set(clusters))
4
Nous allons à présent représenter les clusters obtenus sur deux dimensions :
pca = PCA(n_components=2)
pca.fit(delay_scaled)
PCA(n_components=2)
# Le pourcentage de variance expliqué par les deux premières composantes
pca.explained_variance_ratio_.cumsum()
array([0.26284737, 0.48403171])
Seulement 48% de variance expliquée.
# Affichage des centroïdes en 2 dimensions
reduced_centers = pca.transform(model.cluster_centers_)
# reduced_centers
/home/sylwia/.local/lib/python3.9/site-packages/sklearn/base.py:450: UserWarning: X does not have valid feature names, but PCA was fitted with feature names warnings.warn(
# Transformation PCA : affichage des deux composantes
delay_transf = pd.DataFrame(pca.transform(
delay_scaled), columns=['PC1', 'PC2'])
# Merge avec la table "clusters" pour avoir l'info sur le cluster attribué
delay_transf['cluster'] = clusters
delay_transf.head()
| PC1 | PC2 | cluster | |
|---|---|---|---|
| 0 | 3.892 | 0.465 | 2 |
| 1 | 3.715 | 0.077 | 2 |
| 2 | -1.516 | 0.125 | 1 |
| 3 | -1.578 | 0.087 | 1 |
| 4 | -1.193 | 1.501 | 1 |
plt.figure(figsize=(10, 6))
plt.scatter(delay_transf[delay_transf['cluster'] == 0].loc[:, 'PC1'],
delay_transf[delay_transf['cluster'] == 0].loc[:, 'PC2'], color='red')
plt.scatter(delay_transf[delay_transf['cluster'] == 1].loc[:, 'PC1'],
delay_transf[delay_transf['cluster'] == 1].loc[:, 'PC2'], color='blue')
plt.scatter(delay_transf[delay_transf['cluster'] == 2].loc[:, 'PC1'],
delay_transf[delay_transf['cluster'] == 2].loc[:, 'PC2'], color='yellow')
plt.scatter(delay_transf[delay_transf['cluster'] == 3].loc[:, 'PC1'],
delay_transf[delay_transf['cluster'] == 3].loc[:, 'PC2'], color='orange')
#plt.scatter(delay_transf[delay_transf['cluster'] == 4].loc[:, 'PC1'], delay_transf[delay_transf['cluster'] == 4].loc[:, 'PC2'], color='cyan')
#plt.scatter(delay_transf[delay_transf['cluster'] == 5].loc[:, 'PC1'], delay_transf[delay_transf['cluster'] == 5].loc[:, 'PC2'], color='magenta')
#plt.scatter(delay_transf[delay_transf['cluster'] == 6].loc[:, 'PC1'], delay_transf[delay_transf['cluster'] == 6].loc[:, 'PC2'], color='brown')
plt.scatter(reduced_centers[:4, 0], reduced_centers[:4,
1], color='black', marker='x', s=200)
plt.xlabel("PC1")
plt.ylabel("PC2")
plt.show()
Nous remarquons quatre clusters bien distincts, chacun avec son centroïde. Une meilleure visualisation plus bas avec le silhouette vizualiser.
score_silh(delay_scaled)
Le coefficient de silhouette augmente avec le nombre de clusters. Il faut privilégier les clusters pour lesquels le coefficient est le plus élevé : le 4 ou 5.
#pca = decomposition.PCA(n_components=2)
pca.fit(delay_scaled)
delay_transf = pca.transform(delay_scaled)
kmeans_make_cls(delay_scaled, delay_transf, cls1=4, cls2=5)
silh_vizualizer(delay_scaled, cls=4)
Conclusions pour k = 4 :
A présent nous allons afficher les distances entre 4 clusters :
# Intercluster distance Map with best k
plt.figure(figsize=(10, 5))
distance_visualizer = InterclusterDistance(KMeans(n_clusters=4))
distance_visualizer.fit(delay_scaled)
distance_visualizer.show()
plt.show()
Sur cette projection en 2D, on remarque que les différents clusters sont bien séparés, pas de chevauchements.
Nous allons afficher par la suite les résultats pour k=5 :
silh_vizualizer(delay_scaled, cls=5)
Conclusions pour k=5 :
Nous allons tenter un n_cluster = 3 pour comparer avec les résultats actuels :
silh_vizualizer(delay_scaled, cls=3)
La valeur de 4 pour n_clusters semble être la valeur optimale :
Néanmoins, le clustering effectué pour les variables Fréquence, Récence, Montant et Retard ne donne pas de résultats satisfaisants. La feature Retard ne semble pas être orientée à définir des clients intéressants du point de vue marketing.
Les clusters sont des régions denses dans l’espace de données, séparées par des régions de densité de points inférieure. L’ algorithme DBSCAN est basé sur cette notion intuitive de «clusters» et de «bruit». L’idée clé est que pour chaque point d’un cluster, le voisinage d’un rayon donné doit contenir au moins un nombre minimum de points.
Contrairement à l’algorithme des K-Means ou la classification ascendante hiérarchique, il n’y a pas besoin de définir en amont le nombre de clusters ce qui rend l’algorithme moins rigide.
Eléments-cles
L’algorithme DBSCAN nécessite deux paramètres :
eps alors ils sont considérés comme voisins. rayon eps. min_samples doit être choisie. min_samples minimales peuvent être obtenues à partir du nombre de dimensions D dans l’ensemble de données comme, min_samples >= D+1. Dans cet algorithme, nous avons 3 types de points de données.
Point central / Core point : Un point est un point central s’il a plus de points MinPts dans eps.Border Point : Un point qui a moins de MinPts dans eps mais qui se trouve au voisinage d’un point central.Bruit / Noise ou valeur aberrante : un point qui n’est pas un point central ou un point frontière.from sklearn.neighbors import NearestNeighbors
from sklearn.cluster import DBSCAN
def count_epsilon(df_pca):
nearest_neighbors = NearestNeighbors(n_neighbors=101)
neighbors = nearest_neighbors.fit(df_pca)
distances, indices = neighbors.kneighbors(df_pca)
distances = np.sort(distances[:, 100], axis=0)
plt.figure(figsize=(10, 6))
plt.plot(distances)
plt.title("K distances - calcul de ε")
plt.xlabel("Points")
plt.ylabel("Distance")
plt.show()
#plt.savefig("Distance_curve.png", dpi=300)
def draw_dbscan(eps, min_samples, df_scaled, df_pca):
clusters = DBSCAN(eps=eps, min_samples=min_samples).fit(df_scaled)
# Number of Clusters
labels = clusters.labels_
N_clus = len(set(labels))-(1 if -1 in labels else 0)
print('Nombre de clusters : %d' % N_clus)
# Identify Noise
n_noise = list(clusters.labels_).count(-1)
print('Nombre de bruits : %d' % n_noise)
# Plot the prediction
y_pred = DBSCAN(eps=eps, min_samples=min_samples).fit_predict(df_scaled)
plt.figure(figsize=(12, 8))
ax = plt.axes()
ax.set_facecolor("lightgrey")
plt.scatter(df_pca['PC1'], df_pca['PC2'], c=y_pred)
plt.show()
pca = PCA(n_components=2)
pca.fit(rfm_scaled)
PCA(n_components=2)
# Transformation PCA : affichage des deux composantes principales
rfm_transf = pd.DataFrame(pca.transform(rfm_scaled), columns=['PC1', 'PC2'])
df_visualize(rfm_transf)
*c* argument looks like a single numeric RGB or RGBA sequence, which should be avoided as value-mapping will have precedence in case its length matches with *x* & *y*. Please use the *color* keyword-argument or provide a 2D array with a single row if you intend to specify the same RGB or RGBA value for all points.
Plutôt que d'expérimenter différentes valeurs d'epsilon, nous pouvons utiliser la méthode du coude pour obtenir une valeur d'epsilon convenable.
Sur un graphique de k-distances, la valeur optimale pour epsilon est le point situé sur la courbe. Implémentons cette technique ci-dessous et générons un graphique.
count_epsilon(rfm_transf)
Nous allons choisir un ε de telle sorte que 90% des observations aient une distance au proche voisin inférieure à ε. Dans notre exemple 0.3 voire 0.5 semblent convenir.
Nous allons donc générer les clusters avec ces valeurs et calculer le nombre de clusters ainsi que le nombre d'éventuels bruits.
Min_samples à 100
draw_dbscan(eps=0.3, min_samples=100, df_scaled=rfm_scaled, df_pca=rfm_transf)
Nombre de clusters : 3 Nombre de bruits : 3549
draw_dbscan(eps=0.4, min_samples=100, df_scaled=rfm_scaled, df_pca=rfm_transf)
Nombre de clusters : 2 Nombre de bruits : 2759
draw_dbscan(eps=0.5, min_samples=100, df_scaled=rfm_scaled, df_pca=rfm_transf)
Nombre de clusters : 2 Nombre de bruits : 2311
Conclusion
Les arguments eps et min_samples ont permis de fixer la distance ε et le nombre minimal de voisins (100) pour être considéré comme une observation cœur. L’algorithme a donc détecté :
eps=0.3 : 3 principaux clusters et 3549 anomalieseps=0.4 : 2 principaux clusters et 2759 anomalieseps=0.5 : 2 principaux clusters et 2311 anomaliesLes résultats ne sont pas satisfaisants : non seulement le nombre de clusters ne semble pas suffisant, mais en plus le nombre de bruits est très élevé (ce qui équivaut à des données non analysées, c'est-à-dire des clients sans cluster).
Min_samples à 10
draw_dbscan(eps=0.3, min_samples=10, df_scaled=rfm_scaled, df_pca=rfm_transf)
Nombre de clusters : 15 Nombre de bruits : 759
draw_dbscan(eps=0.4, min_samples=10, df_scaled=rfm_scaled, df_pca=rfm_transf)
Nombre de clusters : 9 Nombre de bruits : 399
draw_dbscan(eps=0.5, min_samples=10, df_scaled=rfm_scaled, df_pca=rfm_transf)
Nombre de clusters : 6 Nombre de bruits : 298
Conclusion
L'argumentmin_samples défini à 10 a permis de détecter :
eps=0.3 : 17 principaux clusters et 854 anomalieseps=0.4 : 9 principaux clusters et 450 anomalieseps=0.5 : 6 principaux clusters et 298 anomaliesLes résultats ne sont pas satisfaisants. Les clusters proposés sont trop nombreux, par conséquent ils ne contiennent pas un nombre suffisant de profils clients.
Analyse du bruit
Essayons d'afficher le nombre de clients sans cluster (cluster « -1 »).
# Reprise des paramétres du premier cas de l'analyse plus haut
db = DBSCAN(eps=0.3, min_samples=100).fit(rfm_scaled)
labels = db.labels_
# Vérification du bruits / nombre de clusters
no_clusters = len(np.unique(labels))
no_noise = np.sum(np.array(labels) == -1, axis=0)
print('Estimated no. of clusters: %d' % no_clusters)
print('Estimated no. of noise points: %d' % no_noise)
Estimated no. of clusters: 4 Estimated no. of noise points: 3549
# Définition de couleur pour les clients sans cluster
colors = list(map(lambda x: '#3b4cc0' if x == -1 else '#b40426', labels))
fig = plt.figure(figsize=(12, 6))
sns.scatterplot(rfm_transf.iloc[:, 0], rfm_transf.iloc[:, 1], c=colors, hue=[
"Cluster {}".format(x) for x in labels])
plt.title('Les clusters et le bruit')
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.show()
/home/sylwia/.local/lib/python3.9/site-packages/seaborn/_decorators.py:36: FutureWarning: Pass the following variables as keyword args: x, y. From version 0.12, the only valid positional argument will be `data`, and passing other arguments without an explicit keyword will result in an error or misinterpretation. warnings.warn(
Chacune des observations est étiquetée avec le nom du cluster correspondant (-1,0,1,2). Les anomalies sont étiquetées en -1 et forment donc une classe à part en plus des 3 clusters détectés.
Nous constatons que la classe -1 est très, voire trop, nombreuse.
C’est une méthode hiérarchique qui se construit étape par étape en partant du niveau le plus fin où chaque individu est seul dans son groupe jusqu’au niveau le plus agrégé où tous les individus sont dans le même groupe. Pour présenter la manière dont tout cela s’organise on construit un dendrogramme.
Eléments-clés
L'algorithme CAH nécessite trois paramètres :
la distance euclidienne.n_clusters permet de renseigner le nombre de clusters souhaité pour la classification.le critère de Wardrfm_only.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 40597 entries, 0 to 41093 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Récence 40597 non-null int64 1 Fréquence 40597 non-null int64 2 Montant 40597 non-null float64 dtypes: float64(1), int64(2) memory usage: 1.2 MB
Nous allons effectuer le clustering sur un échantillon, étant donné que le CAH est un algorithme assez lourd et chronophage :
#Echantillonage : 10% du jeu de données initial
rfm_sampled = rfm_only.sample(frac=0.10, random_state=1)
rfm_sampled.shape
(4060, 3)
rfm_sampled.head()
| Récence | Fréquence | Montant | |
|---|---|---|---|
| 14033 | 32 | 1 | 28.570 |
| 623 | 80 | 1 | 107.070 |
| 29776 | 146 | 2 | 293.360 |
| 22130 | 75 | 1 | 31.340 |
| 10924 | 112 | 2 | 669.960 |
Normalisation
# Normalisation des données
scaler = StandardScaler()
rfm_sampled_scaled = pd.DataFrame(scaler.fit_transform(
rfm_sampled), columns=rfm_sampled.columns)
rfm_sampled_scaled.head()
| Récence | Fréquence | Montant | |
|---|---|---|---|
| 0 | -1.090 | -0.314 | -0.726 |
| 1 | -0.588 | -0.314 | -0.329 |
| 2 | 0.102 | 1.224 | 0.613 |
| 3 | -0.640 | -0.314 | -0.712 |
| 4 | -0.254 | 1.224 | 2.517 |
Dendrogramme
L’algorithme CAH demande en amont de définir le nombre de partitions.
Le dendrogramme permet de visualiser les regroupements successifs jusqu’à obtenir un unique cluster. Il est souvent pertinent de choisir le partitionnement correspondant au plus grand saut entre deux clusters consécutifs.
import scipy.cluster.hierarchy as sch
Pour construire le dendrogramme, il faut choisir une méthode d’agrégation. Parmi de nombreuses solutions qui existent (saut minimum, distance maximum, moyenne, Ward…), nous privilégierons la méthode de Ward.
De manière simplifiée, cette méthode cherche à minimiser l’inertie intra-classe et à maximiser l’inertie inter-classe afin d’obtenir des classes les plus homogènes possibles.
plt.figure(figsize=(14, 10))
dendrogram = sch.dendrogram(sch.linkage(rfm_sampled_scaled, method="ward"))
plt.title('Dendrogram')
plt.xlabel('Customers')
plt.ylabel('Euclidean distances')
plt.show()
L'axe des abscisses représente les clients et l'axe des ordonnées est constitué de la distance euclidienne entre les clusters.
Afin de déterminer le nombre optimal de clusters, nous recherchons la plus grande distance verticale sans traverser aucune ligne horizontale. Observons la répartition des individus au niveau du seuil 30 de la distance euclidienne. Nous allons demander à sklearn de générer 6 clusters :
from sklearn.cluster import AgglomerativeClustering
hc = AgglomerativeClustering(
n_clusters=4, affinity='euclidean', linkage='ward')
hc.set_params(n_clusters=6)
clusters_hc = hc.fit_predict(rfm_sampled_scaled)
np.bincount(clusters_hc)
array([ 127, 1494, 771, 408, 136, 1124])
Les clusters ne sont pas tout à fait homogènes : 127 ou 136 contre 1494 ou 1124.
Vérifions comment seront formés les clusters pour n_clusters = 4 et 3 :
hc.set_params(n_clusters=3)
clusters_hc = hc.fit_predict(rfm_sampled_scaled)
np.bincount(clusters_hc)
array([ 671, 2618, 771])
hc.set_params(n_clusters=4)
clusters_hc = hc.fit_predict(rfm_sampled_scaled)
np.bincount(clusters_hc)
array([ 544, 2618, 771, 127])
X_clustered = pd.DataFrame(
rfm_sampled_scaled, columns=rfm_sampled.columns, index=rfm_sampled.index)
X_clustered['cluster'] = clusters_hc
Idem, on observe un décalage en termes de la volumétrie des clusters. Essayons d'interpréter les résultats à l'aide d'un boxplot pour 4 clusters :
X_clustered.boxplot(by="cluster", figsize=(14, 6), layout=(1, 3))
plt.show()
Conclusions
Voilà l'interprétation des caractéristiques des clusters respectifs :
Cluster 0 :
Cluster 1 :
Cluster 2 :
Cluster 3 :
Parmi toutes les méthodes de clustering appliquées ci-dessus, c'est le K-Means qui semble être le plus efficace. Le CAH donne des résultats similaires, en revanche les temps de performances laissent à désirer.
Les résultats de K-Means sont parfaitement interprétables et utilisables par une équipe marketing. Néanmoins, la segmentation RFM semble fournir une analyse des profils client tout aussi pertinente. Une comparaison des deux méthodes pourrait donner de bons résultats (note : il faudrait peut-être rétrecir la liste des comportements d'achat en fusionnant certains profils - ceux similaires ou ceux les moins nombreux).